<?php declare(strict_types=1);
/**
 * @author      xianganyall <xianganyall@gmail.com>
 * @copyright   2023-2025 owner
 **/

namespace Srv\Libs\Tools;

use Srv\Libs\Common\CommString;
use Swoole\Client;

class WebSocketClient
{
    public const UUID               = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    public const TYPE_TEXT          = 'text';
    public const TYPE_BINARY        = 'binary';
    public const TYPE_CLOSE         = 'close';
    public const TYPE_PING          = 'ping';
    public const TYPE_PONG          = 'pong';
    public const ENTER              = "\n";
    public const ENTER_BR           = "\r\n";
    public const PROTOCOL_WS        = 'ws';
    public const PROTOCOL_WSS       = 'wss';
    public const PORT_DEFAULT_WS    = 80;
    public const PORT_DEFAULT_WSS   = 443;
    public const TOKEN_LENGTH       = 16;
    private string $scheme          = self::PROTOCOL_WS;
    private string $host            = 'localhost';
    private int $port               = self::PORT_DEFAULT_WS;
    private string $path            = '/';
    private ?Client $Client         = null;
    private bool $connected         = false;
    private bool $reConnected       = false;
    private string $bufferNext      = '';
    private string $uri             = '';
    private int $timeOut            = 5;
    private string $origin          = '';
    private string $userAgent       = '';
    private array $clientConf       = [];

    /**
     * __destruct
     */
    public function __destruct()
    {
        $this->close();
    }

    /**
     * @param string $uri
     * @param int $timeOut
     * @param string $origin
     * @param string $userAgent
     * @param array $clientConf
     * @param array $rspData
     * @return bool
     * connect
     */
    public function connect(string $uri, int $timeOut = 5, string $origin = '', string $userAgent = '', array $clientConf = [], array &$rspData = []):bool
    {
        $this->connected    = false;
        if($timeOut < 1) return false;
        if(!$this->parseUri($uri)) return false;
        if(!$this->reConnected){
            $this->uri          = $uri;
            $this->timeOut      = $timeOut;
            $this->origin       = $origin;
            $this->userAgent    = $userAgent;
            $this->clientConf   = $clientConf;
        }
        $this->Client       = new Client(($this->scheme === self::PROTOCOL_WSS ? SWOOLE_SOCK_TCP|SWOOLE_SSL : SWOOLE_SOCK_TCP) | SWOOLE_KEEP);
        if(count($clientConf) > 0) $this->Client->set($clientConf);
        if(!$this->Client->connect($this->host, $this->port, $timeOut)) return false;
        $secKey             = base64_encode(CommString::genRandStr(self::TOKEN_LENGTH));
        $requestHeader      = 'GET '.$this->path.' HTTP/1.1'.self::ENTER_BR.
            'Origin: '.(strlen($origin) < 1 ? 'null' : $origin).self::ENTER_BR.
            'Host: '.$this->host.(in_array($this->port, [0, self::PORT_DEFAULT_WS, self::PORT_DEFAULT_WSS], true)?'':':'.$this->port).self::ENTER_BR.
            'Sec-WebSocket-Key: '.$secKey.self::ENTER_BR.
            'User-Agent: '.(strlen($userAgent) < 1 ? 'WebSocketClient/1.0.0.0' : $userAgent).self::ENTER_BR.
            'Pragma: no-cache'.self::ENTER_BR.
            'Cache-Control: no-cache'.self::ENTER_BR.
            'Upgrade: websocket'.self::ENTER_BR.
            'Connection: Upgrade'.self::ENTER_BR.
            'Sec-WebSocket-Protocol: wamp'.self::ENTER_BR.
            'Sec-WebSocket-Version: 13'.self::ENTER_BR.self::ENTER_BR;
        $this->Client->send($requestHeader);
        $recvStr                    = $this->Client->recv();
        if(is_string($recvStr)){
            $rspData                = $this->parseResponse($recvStr);
            if(isset($rspData['status']) && $rspData['status'] === 101 && isset($rspData['Sec-Websocket-Accept']) && $rspData['Sec-Websocket-Accept'] === base64_encode(pack('H*', sha1($secKey.self::UUID)))) $this->connected = true;
        }
        if(!$this->connected) $this->close();
        return $this->connected;
    }

    /**
     * @param array $rspData
     * @return bool
     * reconnect
     */
    public function reconnect(array &$rspData = []):bool
    {
        $this->close();
        $this->reConnected  = true;
        return $this->connect($this->uri, $this->timeOut, $this->origin, $this->userAgent, $this->clientConf, $rspData);
    }

    /**
     * @return void
     * close
     */
    public function close()
    {
        if($this->Client instanceof Client){
            if($this->connected) $this->Client->close();
            $this->Client   = null;
        }
        $this->connected    = false;
    }

    /**
     * @return Client
     * getClient
     */
    public function getClient():Client
    {
        return $this->Client;
    }

    /**
     * @return bool
     * isConnected
     */
    public function isConnected():bool
    {
        return $this->connected;
    }

    /**
     * @return array
     * recv
     */
    public function recv():array
    {
        $recvData       = [];
        $buffer         = '';
        $dataComplete   = false;
        do{
            if(strlen($this->bufferNext) < 4){
                $recvStr        = $this->Client->recv();
                if(!is_string($recvStr) || strlen($recvStr) < 1){   // fail
                    return $recvData;
                }
                $buffer         .= $recvStr;
            }else{
                $buffer         = $this->bufferNext;
            }
            $bufferNext         = '';
            $recvData           = $this->hyBi10Decode($buffer, $dataComplete, $bufferNext);
            $this->bufferNext   = $bufferNext;
        }while(!$dataComplete && isset($recvData['type']) && isset($recvData['payload']));
        return $recvData;
    }

    /**
     * @param string $payload
     * @param string $type
     * @param bool $masked
     * @return bool
     * send
     */
    public function send(string $payload, string $type = self::TYPE_TEXT, bool $masked = true):bool
    {
        return $this->Client->send($this->hyBi10Encode($payload, $type, $masked)) !== false;
    }

    /**
     * @param string $text
     * @param bool $masked
     * @return bool
     * sendText
     */
    public function sendText(string $text, bool $masked = true):bool
    {
        return $this->Client->send($this->hyBi10Encode($text, self::TYPE_TEXT, $masked)) !== false;
    }

    /**
     * @param string $binary
     * @param bool $masked
     * @return bool
     * sendBinary
     */
    public function sendBinary(string $binary, bool $masked = true):bool
    {
        return $this->Client->send($this->hyBi10Encode($binary, self::TYPE_BINARY, $masked)) !== false;
    }

    /**
     * @param string $close
     * @param bool $masked
     * @return bool
     * sendClose
     */
    public function sendClose(string $close, bool $masked = true):bool
    {
        return $this->Client->send($this->hyBi10Encode($close, self::TYPE_CLOSE, $masked)) !== false;
    }

    /**
     * @param string $ping
     * @param bool $masked
     * @return bool
     * sendPing
     */
    public function sendPing(string $ping, bool $masked = true):bool
    {
        return $this->Client->send($this->hyBi10Encode($ping, self::TYPE_PING, $masked)) !== false;
    }

    /**
     * @param string $pong
     * @param bool $masked
     * @return bool
     * sendPong
     */
    public function sendPong(string $pong, bool $masked = true):bool
    {
        return $this->Client->send($this->hyBi10Encode($pong, self::TYPE_PONG, $masked)) !== false;
    }

    /**
     * @param string $uri
     * @return bool
     * parseUri
     */
    private function parseUri(string $uri):bool
    {
        if(strlen($uri) < 6) return false;
        $uriData        = parse_url($uri);
        $this->scheme   = strtolower($uriData['scheme']??'');
        $this->host     = strtolower($uriData['host']??'');
        $this->port     = intval($uriData['port']??0);
        $this->path     = trim($uriData['path']??'');
        if($this->port < 1) $this->port = $this->scheme === self::PROTOCOL_WSS ? self::PORT_DEFAULT_WSS : self::PORT_DEFAULT_WS;
        if(isset($uriData['query'])) $this->path .= '?'.$uriData['query'];
        if(isset($uriData['fragment'])) $this->path .= '#'.$uriData['fragment'];
        if(strlen($this->host) < 1 || strlen($this->path) < 1 || $this->port < 1 || !in_array($this->scheme, [self::PROTOCOL_WS, self::PROTOCOL_WSS], true)) return false;
        return true;
    }

    /**
     * @param string $recvStr
     * @return array
     * parseResponse
     */
    private function parseResponse(string $recvStr):array
    {
        $rspData    = [];
        if(strlen($recvStr) < 6) return $rspData;
        $content    = '';
        $lineList   = explode(self::ENTER, preg_replace('/\x0D\x0A[\x09\x20]+/', ' ', $recvStr));
        foreach ($lineList as $lineStr){
            $lineStr                    = trim($lineStr);
            if(preg_match('/([^:]+): (.+)/m', $lineStr, $match)){
                $match[1]               = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function ($matches){ return strtoupper($matches[0]); }, strtolower(trim($match[1])));
                if(isset($rspData[$match[1]])){
                    $rspData[$match[1]] = [$rspData[$match[1]], $match[2]];
                }else{
                    $rspData[$match[1]] = trim($match[2]);
                }
            }else if(preg_match('/^HTTP\/1\.\d (\d+) ./', $lineStr, $lineMatch)){
                $rspData['status']      = intval($lineMatch[1]);
            }else{
                $content                .= $lineStr;
            }
        }
        $rspData['content']             = $content;
        return $rspData;
    }

    /**
     * @param string $payload
     * @param string $type
     * @param bool $masked
     * @return string
     * hyBi10Encode
     */
    private function hyBi10Encode(string $payload, string $type = self::TYPE_TEXT, bool $masked = true):string
    {
        $frameHead              = [];
        $payloadLength          = strlen($payload);
        switch($type){
            case self::TYPE_TEXT:{      // first byte indicates FIN, Text-Frame (10000001):
                $frameHead[0]   = 129;
                break;
            }
            case self::TYPE_BINARY:{    // first byte indicates FIN, Binary-Frame (10000010):
                $frameHead[0]   = 130;
                break;
            }
            case self::TYPE_CLOSE:{     // first byte indicates FIN, Close Frame(10001000):
                $frameHead[0]   = 136;
                break;
            }
            case self::TYPE_PING:{      // first byte indicates FIN, Ping frame (10001001):
                $frameHead[0]   = 137;
                break;
            }
            case self::TYPE_PONG:{      // first byte indicates FIN, Pong frame (10001010):
                $frameHead[0]   = 138;
                break;
            }
            default:{
                $frameHead[0]   = 129;
            }
        }
        // set mask and payload length (using 1, 3 or 9 bytes)
        if($payloadLength > 65535){
            $payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8);
            $frameHead[1]   = $masked ? 255 : 127;
            for($i = 0; $i < 8; $i++){
                $frameHead[$i + 2] = bindec($payloadLengthBin[$i]);
            }
            if ($frameHead[2] > 127){   // 最高有效位必须为 0（如果帧太大则关闭连接）
                return '';              // 内容太长了
            }
        }else if($payloadLength > 125){
            $payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8);
            $frameHead[1]   = $masked ? 254 : 126;
            $frameHead[2]   = bindec($payloadLengthBin[0]);
            $frameHead[3]   = bindec($payloadLengthBin[1]);
        }else{
            $frameHead[1]   = $masked ? $payloadLength + 128 : $payloadLength;
        }
        // convert to string
        foreach(array_keys($frameHead) as $i) $frameHead[$i] = chr($frameHead[$i]);
        // generate mask
        if($masked){
            $mask           = [];
            for($i = 0; $i < 4; $i++) $mask[$i] = chr(rand(0, 255));
            $frameHead      = array_merge($frameHead, $mask);
        }
        $frameStr           = implode('', $frameHead);
        // append payload
        for($i = 0; $i < $payloadLength; $i++) $frameStr .= $masked ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
        return $frameStr;
    }

    /**
     * @param string $data
     * @param bool $dataComplete
     * @param string $bufferNext
     * @return array
     * hyBi10Decode
     */
    private function hyBi10Decode(string $data, bool &$dataComplete, string &$bufferNext):array
    {
        $decodedData        = [];
        $dataSrcLength      = strlen($data);
        if($dataSrcLength < 1) return $decodedData;
        $firstByteBinary    = sprintf('%08b', ord(substr($data, 0, 1)));
        $secondByteBinary   = sprintf('%08b', ord(substr($data, 1, 1)));
        $opcode             = bindec(substr($firstByteBinary, 4, 4));
        $isMasked           = substr($secondByteBinary, 0, 1) === '1';
        $payloadLength      = $isMasked ? ord(substr($data, 1, 1)) & 127 : ord(substr($data, 1, 1));
        $mask               = '';
        if($payloadLength === 126){
            $payloadOffset      = 4;
            if($isMasked){
                $mask           = substr($data, 4, 4);
                $payloadOffset  = 8;
            }
            $dataLength         = bindec(sprintf('%08b', ord(substr($data, 2, 1))) . sprintf('%08b', ord(substr($data, 3, 1)))) + $payloadOffset;
        }elseif($payloadLength === 127){
            $payloadOffset      = 10;
            if($isMasked){
                $mask           = substr($data, 10, 4);
                $payloadOffset  = 14;
            }
            $tmpStr             = '';
            for($i = 0; $i < 8; $i++) $tmpStr .= sprintf('%08b', ord(substr($data, $i+2, 1)));
            $dataLength         = bindec($tmpStr) + $payloadOffset;
        }else{
            $payloadOffset      = 2;
            if($isMasked){
                $mask           = substr($data, 2, 4);
                $payloadOffset  = 6;
            }
            $dataLength         = $payloadLength + $payloadOffset;
        }
        $dataDiff               = $dataSrcLength - $dataLength;
        $dataComplete           = $dataDiff >= 0;
        if($dataDiff > 0){  // 拆分多批次的数据
            $bufferNext         = substr($data, $dataLength);
            $data               = substr($data, 0, $dataLength);
        }
        switch($opcode){
            case 1:{ $opType    = self::TYPE_TEXT; break; }
            case 2:{ $opType    = self::TYPE_BINARY; break; }
            case 8:{ $opType    = self::TYPE_CLOSE; break; }
            case 9:{ $opType    = self::TYPE_PING; break; }
            case 10:{ $opType   = self::TYPE_PONG; break; }
            default:{ $opType   = '*'; }
        }
        $decodedData['type']    = $opType;
        if($isMasked){
            $unmaskedPayload    = '';
            for($i = $payloadOffset; $i < $dataLength; $i++){
                $j              = $i - $payloadOffset;
                if($i < ($dataSrcLength-1)) $unmaskedPayload .= substr($data, $i, 1) ^ $mask[$j % 4];
            }
            $decodedData['payload'] = $unmaskedPayload;
        }else{
            $decodedData['payload'] = substr($data, $payloadOffset);
        }
        return $decodedData;
    }
}